跳到主要内容

Go 语言的垃圾回收机制

Go 的垃圾回收整体流程

Go 的垃圾回收整体流程如下图所示:

GC关键参数

# GOGC环境变量控制GC触发频率
export GOGC=100 # 默认值,内存翻倍时触发GC
export GOGC=200 # 内存变为3倍时触发GC,减少GC频率但增加内存使用
export GOGC=50 # 内存增长50%就触发GC,更频繁但内存占用少

# 查看GC统计信息
go build -gcflags="-m" main.go # 编译时显示逃逸分析
GODEBUG=gctrace=1 ./main # 运行时显示GC trace

Go 语言的三色标记算法

Go 语言从 V1.5 开始使用三色标记算法。三色只是为了叙述上方便抽象出来的一种说法,实际上对象并没有颜色之分。这里的三色,对应了垃圾回收过程中对象的三种状态:

  • 灰色:对象还在标记队列中等待
  • 黑色:对象已被标记,该对象不会在本次GC中被清理
  • 白色:对象未被标记,该对象将会在本次GC中被清理

三色标记算法工作流程

详细过程如下:

  1. 初始状态下所有对象都是白色的。
  2. 从根节点开始遍历所有对象,把遍历到的对象变成灰色对象(备注:这里变成灰色对象的都是根节点的对象)。
  3. 遍历灰色对象,将灰色对象引用的对象(备注:这里指的是灰色对象引用到的所有对象,包括灰色节点间接引用的那些对象)也变成灰色对象,然后将遍历过的灰色对象变成黑色对象。
  4. 循环步骤 3,直到灰色对象全部变黑色。
  5. 通过写屏障 (write-barrier)检测对象有变化,重复以上操作(备注:因为 mark 和用户程序是并行的,所以在上一步执行的时候可能会有新的对象分配,写屏障是为了解决这个问题引入的)。
  6. 收集所有白色对象(垃圾)。

根对象包括哪些?

根对象是垃圾回收算法中的起始点,它们是程序中被直接引用或全局可访问的对象。

根对象是垃圾回收器开始遍历和标记的起点,通过跟踪这些根对象及其引用链,可以确定哪些对象是可达的,哪些对象是不可达的,从而进行垃圾回收。

在 Go 语言中,根对象主要包括以下几个部分:

  1. 全局变量:Go 语言中的全局变量是在包级别声明的变量,它们在整个程序执行期间都存在。垃圾回收器会将全局变量作为根对象来开始遍历和标记其他对象。

  2. 栈对象:Go 语言中的函数调用过程中的局部变量和临时数据存储在栈上。垃圾回收器会扫描当前活跃的协程的栈,将栈对象作为根对象来进行垃圾回收。

  3. 其他系统级别的根对象:Go 语言运行时系统会维护一些与系统相关的根对象,例如 goroutine 调度器中的数据结构等。这些根对象也会被垃圾回收器作为根对象来遍历和标记其他对象。

为什么需要灰色状态?

灰色对象在三色标记法中的作用是帮助确定需要继续遍历的对象集合。具体来说,灰色对象表示已经被标记为不需要回收的对象,但其引用的其他对象尚未被标记。这些灰色对象的引用链可能指向尚未遍历的其他对象,因此需要进一步探索和标记这些对象。

为什么需要灰色对象呢?主要有两个原因:

  1. 遍历引用链:灰色对象表示需要遍历其引用链以继续标记其他对象。在垃圾回收过程中,从根对象(例如全局变量、堆栈中的变量)出发,通过对象之间的引用关系,逐步遍历对象图。灰色对象的引用链可能指向其他未被标记的对象,因此需要继续遍历并标记这些对象,确保所有可达的对象都被正确标记。
  2. 并发标记:三色标记法是一种并发垃圾回收算法,它允许垃圾回收器在不停止应用程序的情况下进行工作。灰色对象的引入允许垃圾回收器在标记阶段并发地遍历和标记对象图。在并发标记的过程中,灰色对象的引用链可能被其他线程或协程修改,但由于并发标记的机制,可以安全地继续进行遍历和标记。

为什么垃圾回收需要 STW?

在垃圾回收 (GC) 过程中,STW(Stop-The-World) 就是让应用暂停的机制。暂停的目的是:

  • GC 可以在 一个一致、稳定的内存快照 上操作
  • 避免程序线程并发修改对象引用,导致 标记不完整、回收错误
  • 安全地执行涉及对象移动、指针更新的操作

下面使用一个时序图展示 GC 与应用线程在 STW 前、中、后的行为

STW 的主要任务

  1. 让所有线程进入“安全点”

    • 所有线程必须暂停在能保证状态一致的位置,例如函数调用边界或特定指令边界。
    • 这样,GC 能够在 一个不会再变化的引用关系快照 上工作。
  2. 根扫描 (Root Scanning)

    • 从全局变量、栈、寄存器中找出“活着”的对象引用。
    • 如果应用没停下,根集合还在变,就无法保证扫描结果正确。
  3. 同步写屏障信息

    • 在并发 GC 中,应用线程可能在写屏障里积累了一些“修改记录”,STW 时必须收集并合并这些信息,避免漏标。
  4. 可能的对象移动(整理/压缩型 GC)

    • 如果 GC 需要移动对象,就必须确保没有其他线程在访问或修改这些对象。STW 提供了这个安全环境。
  5. 少量校正 / 清理

    • 在并发标记或并发清理的 GC 算法里,STW 时间被缩短到“必要的同步步骤”,保证 GC 阶段之间的过渡是正确的。

新版本的 STW 优化

老式 STW GC vs 现代并发 GC 对比

Go 版本阶段GC 技术STW 停顿时间关键手段
Go 1.5 之前Stop-the-world GC毫秒 ~ 数百毫秒一次完整 GC 暂停
Go 1.5 (并发 GC)并发标记 + 写屏障~10ms 级三色标记、写屏障
Go 1.9并发 GC + 辅助 GC<1msMutator Assist、调度器优化
Go 1.19+现代并发 GC100~200µs写屏障优化 + 并发清理

什么是辅助 GC?(Mutator Assist 机制)

在 Go 的垃圾回收里,有一个难题:如果垃圾回收器单独工作,程序必须频繁进入 STW(Stop-The-World) 暂停,才能让 GC 完成标记和清理,这会让程序出现明显卡顿。

为减少这些卡顿,Go 引入了 辅助 GC(Mutator Assist)。它的作用就是:
👉 让正在运行的用户线程在分配内存时,顺便“帮忙”做一部分 GC 工作,目的是避免 应用程序分配内存的速度远远超过垃圾回收器的回收速度时,GC 跟不上而导致 heap 无限膨胀。

这样,垃圾回收的负担不会集中到 GC 线程身上,从而减少必须暂停的时间。

  • 当内存分配速度过快,而 GC 并发标记/回收还没来得及处理 时,就可能造成堆膨胀
  • 如果 GC 落后太多,就不得不再次拉长 STW 来追赶 → 影响用户体验。

核心思路:让分配者自己帮忙做 GC 的脏活

  1. Go 运行时维护一个全局 GC 进度目标:标记速度要至少跟得上分配速度。
  2. 每次 Mutator 想分配对象时,分配器会检查当前 GC 进度是否落后
  3. 如果 GC 落后了,Mutator 分配新对象时会被“拖慢”:
    • 在分配成功之前,强制 Mutator 做一些标记工作(比如自己扫一部分堆里还没标记的对象)。
    • 这样就能让分配速度与标记速度保持平衡。
  4. 当 GC 标记进度追上或足够快时,Mutator 分配又可以变快。

整个流程如下:

三色标记实际例子的执行过程

  1. 初始状态:明确标出根对象(栈引用A,全局变量引用F),建立合理的引用关系
  2. 标记根对象:将从根可达的对象A和F标记为灰色
  3. 逐个处理灰色对象
    • 处理A:将其引用的B、C标记为灰色,A变黑色
    • 处理F:无引用对象,F直接变黑色
    • 处理B:将其引用的D标记为灰色,B变黑色
    • 处理C:D已经是灰色,C变黑色
    • 处理D:无引用对象,D变黑色
  4. 清理阶段:回收所有白色对象(E、G、H)

垃圾回收什么时候会被触发?

在 Go 语言中,垃圾回收的触发时机是由运行时系统自动决定的,开发人员无需显式触发垃圾回收。Go 语言的垃圾回收器会在以下情况下自动触发:

1、定时触发:Go语言的垃圾回收器会根据经过的时间来触发垃圾回收。具体的时间间隔由运行时系统根据程序的运行状况和垃圾回收的性能目标动态调整( 默认最长2分钟触发一次)。

2、内存分配触发:垃圾回收器会根据内存分配的情况来触发垃圾回收。当程序进行大量的内存分配时,垃圾回收器会检测到堆上的内存使用达到一定阈值,然后触发垃圾回收以释放不再使用的对象所占用的内存。

// 伪代码示例:分配内存时的检查
func allocateMemory(size int) *Object {
// 分配前检查是否需要触发GC
totalAllocated += size
if totalAllocated > gcTriggerThreshold {
triggerGC()
gcTriggerThreshold = calculateNewThreshold()
}

return &Object{data: make([]byte, size)}
}

// 实际使用场景
func example() {
// 大量内存分配会触发GC
for i := 0; i < 1000000; i++ {
data := make([]byte, 1024) // 每次分配1KB
processData(data)
}
}

3、堆大小触发:垃圾回收器会根据堆的大小来触发垃圾回收。当堆的大小超过一定阈值时,垃圾回收器会被触发,以确保堆的大小在可控范围内。

// 伪代码示例:堆大小监控
func monitorHeapSize() {
currentHeapSize := getHeapSize()
heapGrowthRatio := currentHeapSize / lastGCHeapSize

// 当堆大小增长超过一定比例时触发GC
if heapGrowthRatio > GOGC_PERCENT/100 { // 默认GOGC=100,即堆增长100%触发
triggerGC()
}
}

// 设置GOGC环境变量的效果示例
func setGCTarget() {
// GOGC=100 表示堆大小翻倍时触发GC
// GOGC=200 表示堆大小增长到3倍时触发GC
// GOGC=50 表示堆大小增长50%时触发GC
}

需要注意的是,Go语言的垃圾回收是在后台并发进行的,它不会停止整个程序的执行。垃圾回收器会在后台运行,并在需要的时候暂停程序的执行,对内存中的对象进行标记、清除等操作。

什么是写屏障?

写屏障(Write Barrier)是一种用于维护对象引用关系的机制,它在对象被写入时进行拦截和处理,以确保垃圾回收器能够正确地跟踪对象之间的引用关系。

写屏障的主要目的:

  • 在对象被修改时,将相关的引用关系传递给垃圾回收器
  • 确保被修改对象和引用它的对象都被正确地标记和保留

写屏障的实现步骤:

  1. 拦截写入操作:当程序将一个引用写入对象的字段时,写屏障会拦截该写入操作。它会检查写入的引用是否指向一个堆上的对象,如果是,则继续执行下一步骤;否则,直接进行写入操作,因为堆外对象不需要进行垃圾回收。

  2. 处理引用关系:在写屏障确定写入的引用指向堆上的对象后,它会执行特定的操作来维护对象之间的引用关系。一种常见的处理方式是将该引用添加到一个待处理的队列中,垃圾回收器在适当的时机会扫描这个队列,并根据新的引用关系更新对象的标记状态。

内存逃逸分析

这里会去检查这个变量的生命周期,如果发现这个变量在函数返回后还会被使用,那么这个变量就会逃逸到堆上分配,否则就会分配到栈上。

逃逸分析实例

// 1. 不逃逸 - 栈分配
func noEscape() {
user := User{Name: "张三"} // 在栈上分配,函数结束自动释放
fmt.Println(user.Name)
}

// 2. 逃逸到堆 - 返回指针
func escapeReturn() *User {
user := User{Name: "李四"} // 逃逸到堆,需要GC回收
return &user // 返回局部变量指针
}

// 3. 逃逸到堆 - interface{}
func escapeInterface() {
user := User{Name: "王五"}
fmt.Println(user) // user被转换为interface{},逃逸到堆
}

// 4. 逃逸到堆 - 动态大小
func escapeSlice(n int) {
slice := make([]int, n) // n不是常量,动态大小,逃逸到堆
_ = slice
}

具体的逃逸场景,可以参考文档

docs/编程语言/Go/Go 的内存相关/Go 的内存常见的逃逸分析.md

Reference

Go语言的逃逸分析